import * as path from "path";
import fs from "fs";
import { spawnSync } from "child_process";
import * as webpack from "webpack";
const LicenseCheckerWebpackPlugin = require("license-checker-webpack-plugin");

const config: webpack.Configuration = {
	entry: {
		bundle: ["./src/main-webpack-licenses.ts"]
	},
	mode: "production",
	resolve: {
		alias: {
			// Note: Later in this config file, we'll automatically add paths from `tsconfig.compilerOptions.paths`
			svelte: path.resolve("node_modules", "svelte")
		},
		extensions: [".ts", ".js", ".svelte"],
		mainFields: ["svelte", "browser", "module", "main"]
	},
	output: {
		path: path.resolve(__dirname, "dist"),
		publicPath: "/dist/",
		filename: "[name].js",
		chunkFilename: "[name].[id].js"
	},
	module: {
		rules: []
	},
	plugins: [
		// License Checker Webpack Plugin validates the license compatibility of all dependencies which are compiled into the webpack bundle
		// It also writes the third-party license notices to a file which is displayed in the application
		// https://github.com/microsoft/license-checker-webpack-plugin
		new LicenseCheckerWebpackPlugin({
			allow: "(Apache-2.0 OR BSD-2-Clause OR BSD-3-Clause OR MIT OR 0BSD)",
			emitError: true,
			outputFilename: "third-party-licenses.txt",
			outputWriter: formatThirdPartyLicenses,
			// Workaround for failure caused in WebPack 5: https://github.com/microsoft/license-checker-webpack-plugin/issues/25#issuecomment-833325799
			filter: /(^.*[/\\]node_modules[/\\]((?:@[^/\\]+[/\\])?(?:[^@/\\][^/\\]*)))/,
		}),

		// new SvelteCheckPlugin(),
	],
	experiments: {
		asyncWebAssembly: true,
	},
};

// Load path aliases from the tsconfig.json file
const tsconfigPath = path.resolve(__dirname, "tsconfig.json");
const tsconfig = fs.existsSync(tsconfigPath) ? require(tsconfigPath) : {};

if ("compilerOptions" in tsconfig && "paths" in tsconfig.compilerOptions) {
	const aliases = tsconfig.compilerOptions.paths;

	for (const alias in aliases) {
		const paths = aliases[alias].map((p: string) => path.resolve(__dirname, p));

		// Our tsconfig uses glob path formats, whereas webpack just wants directories
		// We'll need to transform the glob format into a format acceptable to webpack

		const wpAlias = alias.replace(/(\\|\/)\*$/, "");
		const wpPaths = paths.map((p: string) => p.replace(/(\\|\/)\*$/, ""));

		if (config.resolve && config.resolve.alias) {
			if (!(wpAlias in config.resolve.alias) && wpPaths.length) {
				(config.resolve.alias as any)[wpAlias] = wpPaths.length > 1 ? wpPaths : wpPaths[0];
			}
		}
	}
}

module.exports = config;

interface LicenseInfo {
	licenseName: string;
	licenseText: string;
	packages: PackageInfo[]
}

interface PackageInfo {
	name: string;
	version: string;
	author: string;
	repository: string;
}

interface Dependency extends PackageInfo {
	licenseName: string;
	licenseText?: string;
}

function formatThirdPartyLicenses(jsLicenses: {dependencies: Dependency[]}): string {
	let rustLicenses: LicenseInfo[] | undefined;
	if (process.env.SKIP_CARGO_ABOUT === undefined) {
		try {
			rustLicenses = generateRustLicenses();
		} catch (err) {
			// Nothing to show. Error messages were printed above.
		}

		if (rustLicenses === undefined) {
			// This is probably caused by cargo about not being installed
			console.error(
				`
				Could not run \`cargo about\`, which is required to generate license information.
				To install cargo-about on your system, you can run \`cargo install cargo-about\`.
				License information is required on production builds. Aborting.
				`
					.trim()
					.split("\n")
					.map((line) => line.trim())
					.join("\n")
			);
			process.exit(1);
		}
	}

	// Remove the HTML character encoding caused by Handlebars
	let licenses = (rustLicenses || []).map((rustLicense): LicenseInfo => ({
		licenseName: htmlDecode(rustLicense.licenseName),
		licenseText: trimBlankLines(htmlDecode(rustLicense.licenseText)),
		packages: rustLicense.packages.map((packageInfo): PackageInfo => ({
			name: htmlDecode(packageInfo.name),
			version: htmlDecode(packageInfo.version),
			author: htmlDecode(packageInfo.author).replace(/\[(.*), \]/, "$1"),
			repository: htmlDecode(packageInfo.repository),
		})),
	}));

	// De-duplicate any licenses with the same text by merging their lists of packages
	licenses.forEach((license, licenseIndex) => {
		licenses.slice(0, licenseIndex).forEach((comparisonLicense) => {
			if (license.licenseText === comparisonLicense.licenseText) {
				license.packages.push(...comparisonLicense.packages);
				comparisonLicense.packages = [];
				// After emptying the packages, the redundant license with no packages will be removed in the next step's `filter()`
			}
		});
	});

	// Delete the internal Graphite crates, which are not third-party and belong elsewhere
	licenses = licenses.filter((license) => {
		license.packages = license.packages.filter((packageInfo) => !(packageInfo.repository && packageInfo.repository.includes("github.com/GraphiteEditor/Graphite")));
		return license.packages.length > 0;
	});

	// Augment the imported Rust license list with the provided JS license list
	jsLicenses.dependencies.forEach((jsLicense) => {
		const { name, version, author, repository, licenseName } = jsLicense;
		const licenseText = trimBlankLines(jsLicense.licenseText ?? "");

		// Remove the `git+` or `git://` prefix and `.git` suffix
		const repo = repository ? repository.replace(/^.*(github.com\/.*?\/.*?)(?:.git)/, "https://$1") : repository;

		const matchedLicense = licenses.find((license) => trimBlankLines(license.licenseText) === licenseText);

		const packages: PackageInfo = { name, version, author, repository: repo };
		if (matchedLicense) matchedLicense.packages.push(packages);
		else licenses.push({ licenseName, licenseText, packages: [packages] });
	});

	// Sort the licenses, and the packages using each license, alphabetically
	licenses.sort((a, b) => a.licenseName.localeCompare(b.licenseName));
	licenses.sort((a, b) => a.licenseText.localeCompare(b.licenseText));
	licenses.forEach((license) => {
		license.packages.sort((a, b) => a.name.localeCompare(b.name));
	});

	// Generate the formatted text file
	let formattedLicenseNotice = "GRAPHITE THIRD-PARTY SOFTWARE LICENSE NOTICES\n\n";
	if (!rustLicenses) formattedLicenseNotice += "WARNING: Licenses for Rust packages are excluded in debug mode to improve performance — do not release without their inclusion!\n\n";

	licenses.forEach((license) => {
		let packagesWithSameLicense = "";
		license.packages.forEach((packageInfo) => {
			const { name, version, author, repository } = packageInfo;
			packagesWithSameLicense += `${name} ${version}${author ? ` - ${author}` : ""}${repository ? ` - ${repository}` : ""}\n`;
		});
		packagesWithSameLicense = packagesWithSameLicense.trim();
		const packagesLineLength = Math.max(...packagesWithSameLicense.split("\n").map((line) => line.length));

		formattedLicenseNotice += `--------------------------------------------------------------------------------

The following packages are licensed under the terms of the ${license.licenseName} license as printed beneath:
${"_".repeat(packagesLineLength)}
${packagesWithSameLicense}
${"‾".repeat(packagesLineLength)}
${license.licenseText}

`;
	});

	return formattedLicenseNotice;
}

function generateRustLicenses(): LicenseInfo[] | undefined {
	console.info("Generating license information for Rust code");
	// This `about.hbs` file is written so it generates a valid JavaScript array expression which we evaluate below
	const { stdout, stderr, status } = spawnSync("cargo", ["about", "generate", "about.hbs"], {
		cwd: path.join(__dirname, ".."),
		encoding: "utf8",
		timeout: 60000, // One minute
		shell: true,
		windowsHide: true, // Hide the terminal on Windows
	});

	if (status !== 0) {
		if (status !== 101) {
			// Cargo returns 101 when the subcommand wasn't found
			console.error("cargo-about failed", status, stderr);
		}
		return undefined;
	}

	// Make sure the output starts with this expected label, we don't want to eval an error message.
	if (!stdout.trim().startsWith("GENERATED_BY_CARGO_ABOUT:")) {
		console.error("Unexpected output from cargo-about", stdout);
		return undefined;
	}

	// Security-wise, eval() isn't any worse than require(), but it doesn't need a temporary file.
	// eslint-disable-next-line no-eval
	return eval(stdout) as LicenseInfo[];
}

function htmlDecode(input: string): string {
	if (!input) return input;

	const htmlEntities = {
		nbsp: " ",
		copy: "©",
		reg: "®",
		lt: "<",
		gt: ">",
		amp: "&",
		apos: "'",
		quot: `"`,
	};

	return input.replace(/&([^;]+);/g, (entity: string, entityCode: string) => {
		let match;

		const maybeEntity = Object.entries(htmlEntities).find(([key, _]) => key === entityCode);
		if (maybeEntity) {
			return maybeEntity[1];
		}
		// eslint-disable-next-line no-cond-assign
		if ((match = entityCode.match(/^#x([\da-fA-F]+)$/))) {
			return String.fromCharCode(parseInt(match[1], 16));
		}
		// eslint-disable-next-line no-cond-assign
		if ((match = entityCode.match(/^#(\d+)$/))) {
			return String.fromCharCode(~~match[1]);
		}
		return entity;
	});
}

function trimBlankLines(input: string): string {
	let result = input.replace(/\r/g, "");

	while (result.charAt(0) === "\r" || result.charAt(0) === "\n") {
		result = result.slice(1);
	}
	while (result.slice(-1) === "\r" || result.slice(-1) === "\n") {
		result = result.slice(0, -1);
	}

	return result;
}
